Вивчайте розширені техніки виведення типів у JavaScript: зіставлення зі зразком та звуження типів. Створюйте надійний, підтримуваний та передбачуваний код.
Зіставлення зі зразком та звуження типів у JavaScript: розширене виведення типів для надійного коду
Хоча JavaScript є мовою з динамічною типізацією, він значно виграє від статичного аналізу та перевірок на етапі компіляції. TypeScript, надмножина JavaScript, вводить статичну типізацію та суттєво покращує якість коду. Однак навіть у чистому JavaScript або з системою типів TypeScript ми можемо використовувати такі техніки, як зіставлення зі зразком та звуження типів, щоб досягти більш просунутого виведення типів і писати надійніший, легший у підтримці та передбачуваний код. Ця стаття розглядає ці потужні концепції на практичних прикладах.
Розуміння виведення типів
Виведення типів — це здатність компілятора (або інтерпретатора) автоматично визначати тип змінної чи виразу без явних анотацій типів. За замовчуванням JavaScript значною мірою покладається на виведення типів під час виконання. TypeScript робить крок уперед, надаючи виведення типів на етапі компіляції, що дозволяє нам виявляти помилки типів ще до запуску коду.
Розглянемо наступний приклад на JavaScript (або TypeScript):
let x = 10; // TypeScript виводить, що x має тип 'number'
let y = "Hello"; // TypeScript виводить, що y має тип 'string'
function add(a: number, b: number) { // Явні анотації типів у TypeScript
return a + b;
}
let result = add(x, 5); // TypeScript виводить, що result має тип 'number'
// let error = add(x, y); // Це спричинило б помилку TypeScript на етапі компіляції
Хоча базове виведення типів є корисним, його часто недостатньо при роботі зі складними структурами даних та умовною логікою. Саме тут у гру вступають зіставлення зі зразком та звуження типів.
Зіставлення зі зразком: Емуляція алгебраїчних типів даних
Зіставлення зі зразком, що часто зустрічається у функціональних мовах програмування, таких як Haskell, Scala та Rust, дозволяє нам деструктурувати дані та виконувати різні дії залежно від їхньої форми чи структури. У JavaScript немає вбудованого зіставлення зі зразком, але ми можемо емулювати його за допомогою комбінації технік, особливо в поєднанні з дискримінованими об'єднаннями TypeScript.
Дискриміновані об'єднання
Дискриміноване об'єднання (також відоме як теговане об'єднання або варіантний тип) — це тип, що складається з кількох окремих типів, кожен з яких має спільну властивість-дискримінант («тег»), що дозволяє розрізняти їх. Це ключовий будівельний блок для емуляції зіставлення зі зразком.
Розглянемо приклад, що представляє різні види результатів операції:
// TypeScript
type Success = { kind: "success"; value: T };
type Failure = { kind: "failure"; error: string };
type Result = Success | Failure;
function processData(data: string): Result {
if (data === "valid") {
return { kind: "success", value: 42 };
} else {
return { kind: "failure", error: "Invalid data" };
}
}
const result = processData("valid");
// Тепер, як нам обробити змінну 'result'?
Тип `Result
Звуження типів за допомогою умовної логіки
Звуження типів — це процес уточнення типу змінної на основі умовної логіки або перевірок під час виконання. Перевірка типів у TypeScript використовує аналіз потоку керування, щоб зрозуміти, як типи змінюються в умовних блоках. Ми можемо використовувати це для виконання дій на основі властивості `kind` нашого дискримінованого об'єднання.
// TypeScript
if (result.kind === "success") {
// Тепер TypeScript знає, що 'result' має тип 'Success'
console.log("Success! Value:", result.value); // Тут немає помилок типів
} else {
// Тепер TypeScript знає, що 'result' має тип 'Failure'
console.error("Failure! Error:", result.error);
}
Усередині блоку `if` TypeScript знає, що `result` є `Success
Розширені техніки звуження типів
Крім простих операторів `if`, ми можемо використовувати кілька розширених технік для більш ефективного звуження типів.
Охоронці типів `typeof` та `instanceof`
Оператори `typeof` та `instanceof` можна використовувати для уточнення типів на основі перевірок під час виконання.
function processValue(value: string | number) {
if (typeof value === "string") {
// Тут TypeScript знає, що 'value' є рядком
console.log("Value is a string:", value.toUpperCase());
} else {
// Тут TypeScript знає, що 'value' є числом
console.log("Value is a number:", value * 2);
}
}
processValue("hello");
processValue(10);
class MyClass {}
function processObject(obj: MyClass | string) {
if (obj instanceof MyClass) {
// Тут TypeScript знає, що 'obj' є екземпляром MyClass
console.log("Object is an instance of MyClass");
} else {
// Тут TypeScript знає, що 'obj' є рядком
console.log("Object is a string:", obj.toUpperCase());
}
}
processObject(new MyClass());
processObject("world");
Користувацькі функції-охоронці типів
Ви можете визначати власні функції-охоронці типів для виконання складніших перевірок і повідомлення TypeScript про уточнений тип.
// TypeScript
interface Bird { fly: () => void; layEggs: () => void; }
interface Fish { swim: () => void; layEggs: () => void; }
function isBird(animal: Bird | Fish): animal is Bird {
return (animal as Bird).fly !== undefined; // Качина типізація: якщо є 'fly', ймовірно, це Птах
}
function makeSound(animal: Bird | Fish) {
if (isBird(animal)) {
// Тут TypeScript знає, що 'animal' — це Птах
console.log("Chirp!");
animal.fly();
} else {
// Тут TypeScript знає, що 'animal' — це Риба
console.log("Blub!");
animal.swim();
}
}
const myBird: Bird = { fly: () => console.log("Flying!"), layEggs: () => console.log("Laying eggs!") };
const myFish: Fish = { swim: () => console.log("Swimming!"), layEggs: () => console.log("Laying eggs!") };
makeSound(myBird);
makeSound(myFish);
Анотація типу повернення `animal is Bird` у функції `isBird` є надзвичайно важливою. Вона повідомляє TypeScript, що якщо функція повертає `true`, то параметр `animal` гарантовано має тип `Bird`.
Вичерпна перевірка за допомогою типу `never`
При роботі з дискримінованими об'єднаннями часто корисно переконатися, що ви обробили всі можливі випадки. У цьому може допомогти тип `never`. Тип `never` представляє значення, які *ніколи* не виникають. Якщо певний шлях коду недосяжний, ви можете присвоїти змінній тип `never`. Це корисно для забезпечення вичерпності при перемиканні по типу об'єднання.
// TypeScript
type Shape = { kind: "circle", radius: number } | { kind: "square", sideLength: number } | { kind: "triangle", base: number, height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius * shape.radius;
case "square":
return shape.sideLength * shape.sideLength;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
const _exhaustiveCheck: never = shape; // Якщо всі випадки оброблені, 'shape' матиме тип 'never'
return _exhaustiveCheck; // Цей рядок спричинить помилку компіляції, якщо до типу Shape додати нову фігуру, не оновивши оператор switch.
}
}
const circle: Shape = { kind: "circle", radius: 5 };
const square: Shape = { kind: "square", sideLength: 10 };
const triangle: Shape = { kind: "triangle", base: 8, height: 6 };
console.log("Circle area:", getArea(circle));
console.log("Square area:", getArea(square));
console.log("Triangle area:", getArea(triangle));
//Якщо ви додасте нову фігуру, наприклад,
// type Shape = { kind: "circle", radius: number } | { kind: "square", sideLength: number } | { kind: "rectangle", width: number, height: number };
//Компілятор видасть помилку на рядку const _exhaustiveCheck: never = shape;, оскільки він розуміє, що об'єкт shape може бути { kind: "rectangle", width: number, height: number };
//Це змушує вас обробляти всі випадки типу об'єднання у вашому коді.
Якщо ви додасте нову фігуру до типу `Shape` (наприклад, `rectangle`), не оновивши оператор `switch`, буде виконано випадок `default`, і TypeScript видасть помилку, оскільки не зможе присвоїти новий тип фігури до `never`. Це допомагає виявляти потенційні помилки та гарантує, що ви обробите всі можливі випадки.
Практичні приклади та сценарії використання
Давайте розглянемо деякі практичні приклади, де зіставлення зі зразком та звуження типів є особливо корисними.
Обробка відповідей API
Відповіді API часто надходять у різних форматах залежно від успіху чи невдачі запиту. Дискриміновані об'єднання можна використовувати для представлення цих різних типів відповідей.
// TypeScript
type APIResponseSuccess = { status: "success"; data: T };
type APIResponseError = { status: "error"; message: string };
type APIResponse = APIResponseSuccess | APIResponseError;
async function fetchData(url: string): Promise> {
try {
const response = await fetch(url);
const data = await response.json();
if (response.ok) {
return { status: "success", data: data as T };
} else {
return { status: "error", message: data.message || "Unknown error" };
}
} catch (error) {
return { status: "error", message: error.message || "Network error" };
}
}
// Приклад використання
async function getProducts() {
const response = await fetchData("/api/products");
if (response.status === "success") {
const products = response.data;
products.forEach(product => console.log(product.name));
} else {
console.error("Failed to fetch products:", response.message);
}
}
interface Product {
id: number;
name: string;
price: number;
}
У цьому прикладі тип `APIResponse
Обробка введених користувачем даних
Введені користувачем дані часто вимагають валідації та аналізу. Зіставлення зі зразком та звуження типів можна використовувати для обробки різних типів вводу та забезпечення цілісності даних.
// TypeScript
type ValidEmail = { kind: "valid"; email: string };
type InvalidEmail = { kind: "invalid"; error: string };
type EmailValidationResult = ValidEmail | InvalidEmail;
function validateEmail(email: string): EmailValidationResult {
if (/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email)) {
return { kind: "valid", email: email };
} else {
return { kind: "invalid", error: "Invalid email format" };
}
}
const emailInput = "test@example.com";
const validationResult = validateEmail(emailInput);
if (validationResult.kind === "valid") {
console.log("Valid email:", validationResult.email);
// Обробити валідний email
} else {
console.error("Invalid email:", validationResult.error);
// Показати повідомлення про помилку користувачеві
}
const invalidEmailInput = "testexample";
const invalidValidationResult = validateEmail(invalidEmailInput);
if (invalidValidationResult.kind === "valid") {
console.log("Valid email:", invalidValidationResult.email);
// Обробити валідний email
} else {
console.error("Invalid email:", invalidValidationResult.error);
// Показати повідомлення про помилку користувачеві
}
Тип `EmailValidationResult` представляє або валідну електронну адресу, або невалідну адресу з повідомленням про помилку. Це дозволяє вам коректно обробляти обидва випадки та надавати інформативний зворотний зв'язок користувачеві.
Переваги зіставлення зі зразком та звуження типів
- Підвищена надійність коду: Явно обробляючи різні типи даних та сценарії, ви знижуєте ризик помилок під час виконання.
- Покращена підтримка коду: Код, що використовує зіставлення зі зразком та звуження типів, зазвичай легше розуміти та підтримувати, оскільки він чітко виражає логіку обробки різних структур даних.
- Збільшена передбачуваність коду: Звуження типів гарантує, що компілятор може перевірити коректність вашого коду на етапі компіляції, роблячи його більш передбачуваним та надійним.
- Кращий досвід розробника: Система типів TypeScript надає цінний зворотний зв'язок та автодоповнення, роблячи розробку ефективнішою та менш схильною до помилок.
Виклики та міркування
- Складність: Впровадження зіставлення зі зразком та звуження типів іноді може ускладнити ваш код, особливо при роботі зі складними структурами даних.
- Крива навчання: Розробникам, незнайомим з концепціями функціонального програмування, може знадобитися час для вивчення цих технік.
- Накладні витрати під час виконання: Хоча звуження типів відбувається переважно на етапі компіляції, деякі техніки можуть створювати мінімальні накладні витрати під час виконання.
Альтернативи та компроміси
Хоча зіставлення зі зразком та звуження типів є потужними техніками, вони не завжди є найкращим рішенням. Інші підходи, які варто розглянути, включають:
- Об'єктно-орієнтоване програмування (ООП): ООП надає механізми поліморфізму та абстракції, які іноді можуть досягти схожих результатів. Однак ООП часто може призводити до більш складних структур коду та ієрархій успадкування.
- Качина типізація: Качина типізація покладається на перевірки під час виконання, щоб визначити, чи має об'єкт необхідні властивості або методи. Хоча цей підхід гнучкий, він може призводити до помилок під час виконання, якщо очікувані властивості відсутні.
- Типи об'єднання (без дискримінантів): Хоча типи об'єднання корисні, їм бракує явної властивості-дискримінанта, що робить зіставлення зі зразком більш надійним.
Найкращий підхід залежить від конкретних вимог вашого проєкту та складності структур даних, з якими ви працюєте.
Глобальні аспекти
При роботі з міжнародною аудиторією варто враховувати наступне:
- Локалізація даних: Переконайтеся, що повідомлення про помилки та текст, що відображається користувачеві, локалізовані для різних мов та регіонів.
- Формати дати та часу: Обробляйте формати дати та часу відповідно до локалі користувача.
- Валюта: Відображайте символи та значення валют відповідно до локалі користувача.
- Кодування символів: Використовуйте кодування UTF-8 для підтримки широкого діапазону символів з різних мов.
Наприклад, при валідації введених користувачем даних переконайтеся, що ваші правила валідації підходять для різних наборів символів та форматів вводу, що використовуються в різних країнах.
Висновок
Зіставлення зі зразком та звуження типів — це потужні техніки для написання більш надійного, легкого у підтримці та передбачуваного коду на JavaScript. Використовуючи дискриміновані об'єднання, функції-охоронці типів та інші розширені механізми виведення типів, ви можете покращити якість свого коду та зменшити ризик помилок під час виконання. Хоча ці техніки можуть вимагати глибшого розуміння системи типів TypeScript та концепцій функціонального програмування, переваги варті докладених зусиль, особливо для складних проєктів, що вимагають високого рівня надійності та підтримуваності. Враховуючи глобальні фактори, такі як локалізація та форматування даних, ваші застосунки зможуть ефективно задовольняти потреби різноманітних користувачів.